JavaScript中的EventLoop 机制


前言

让我们先来看一个简单的例子:

Q: 请写出下列JS代码的运行结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
console.log(1);
setTimeout(function () {
console.log(2);
})
var promise = new Promise(
function (resolve, reject) {
console.log(3);
resolve();
}
)
promise.then(function () {
console.log(4);
})
console.log(5);

——题目来自SAST2020应用开发部Web部分招新题。

我们通过浏览器控制台可以很容易得出以下运行结果。

1
2
3
4
5
1
3
5
4
2

作为当时批改前端部分的我,我曾问那些写出运行结果的新同学为什么答案是这个顺序,结果并没有人可以答出个所以然来。当然了,作为刚接触web的同学来说,上来就要你谈JavaScript的事件循环机制确实难了一点。

但是这的确是一个在前端面试中非常常见的一个问题。

相关的问题还有:

  • 请你谈一下浏览器的渲染原理。
  • 请你聊一下JavaScript的运行机制。
  • 请你说一说node的事件循环和浏览器的事件循环区别是什么。
  • 什么是宏任务、微任务?为什么需要引入这样的设定?

这里就简要的讲一下我的一些理解。


事件循环

JavaScript的单线程性

JavaScript语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。

那么,为什么JavaScript不能有多个线程呢?这样能提高效率啊。

这和我们之前聊过的浏览器渲染原理有关系。

浏览器渲染原理:

用户请求的HTML数据通过浏览器的网络层到达渲染引擎后,浏览器的渲染工作开始。

这里我们以Webkit引擎渲染的流程为例,其余引擎大体上相似:

浏览器渲染原理图(webkit)

渲染流程有四个主要步骤:

  1. 解析HTML生成DOM树 - 渲染引擎首先解析HTML文档,生成DOM树
  2. 构建Render树 - 接下来不管是内联式,外联式还是嵌入式引入的CSS样式会被解析生成CSSOM树,根据DOM树与CSSOM树生成另外一棵用于渲染的树-渲染树(Render tree),
  3. 布局Render树 - 然后对渲染树的每个节点进行布局处理,确定其在屏幕上的显示位置
  4. 绘制Render树 - 最后遍历渲染树并用UI后端层将每一个节点绘制出来。

JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动、数据传输以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。

比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?

所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来基本上也不会改变。

当然,为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM,只能做一些计算性质的工作。所以,这个新标准并没有改变JavaScript单线程的本质。

执行栈和事件队列

因为JavaScript的单线程性,所以它的函数调用是通过一个执行栈来实现的。

执行栈: 同步代码的执行,按照顺序添加到执行栈中。

下面是一个简单的例子展现JS执行栈的过程:

1
2
3
4
5
6
7
8
function a() {
b();
console.log('a');
}
function b() {
console.log('b')
}
a();

preview

  1. 执行函数 a()先入栈
  2. a()中先执行函数 b() 函数b() 入栈
  3. 执行函数b(), console.log('b') 入栈
  4. 输出 bconsole.log('b')出栈
  5. 函数b() 执行完成,出栈
  6. console.log('a') 入栈,执行,输出 a, 出栈
  7. 函数a 执行完成,出栈。

但是我们的JS中并不都是顺序执行的脚本,有一些异步执行的代码,例如setTimeout(), setInterval()

或者当我们遇到一些执行时间特别长的代码,我们也需要对其进行异步,防止阻塞JS线程。

事件队列: 异步代码的执行,遇到异步事件不会等待它返回结果,而是将这个事件挂起,继续执行执行栈中的其他任务。当异步事件返回结果,将它放到事件队列中,被放入事件队列不会立刻执行起回调,而是等待当前执行栈中所有任务都执行完毕,主线程空闲状态,主线程会去查找事件队列中是否有任务,如果有,则取出排在第一位的事件,并把这个事件对应的回调放到执行栈中,然后执行其中的同步代码。

简单理解,就是JS在执行中,非异步的代码将放入执行栈中,先入后出,如果遇到异步的代码,则放入事件队列中挂起,等待回调,先入先出。

所谓的事件循环(EventLoop),其实就是异步事件被放入事件队列(Task Queen/Message Queen)中后又经过回调加入主线程中的这样一个循环过程。

事件循环:

(1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。

(2)主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。

(3)一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。

(4)主线程不断重复上面的第三步。

宏任务和微任务

在ES6中,引入了宏任务和微任务的概念。不同的任务被分为:宏任务和微任务

页面渲染事件,各种IO的完成事件等随时被添加到任务队列中,一直会保持先进先出的原则执行,我们不能准确地控制这些事件被添加到任务队列中的位置。但是这个时候突然有高优先级的任务需要尽快执行,那么一种类型的任务就不合适了,所以引入了微任务队列。

宏任务:

  • script(整体代码)
  • setTimeout()
  • setInterval()
  • postMessage
  • I/O
  • UI交互事件

微任务:

  • Promise().then(回调)
  • MutationObserver(html5 新特性)

此时的事件循环机制将对宏任务和微任务进行区分,运行机制如下:

  • 执行一个宏任务(栈中没有就从事件队列中获取)
  • 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
  • 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
  • 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
  • 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)

使用较为形象的语言来说就是:

微任务是跟屁虫,一直跟在当前的宏任务后面,当前宏任务执行完成后,按照微任务队列执行所有微任务,再开始下一个宏任务。

回到最开始的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
console.log(1);
setTimeout(function () {
console.log(2);
})
var promise = new Promise(
function (resolve, reject) {
console.log(3);
resolve();
}
)
promise.then(function () {
console.log(4);
})
console.log(5);

setTimeout()Promise()都是异步事件,但是他们也有顺序。

前者是宏任务,后者是微任务。

值得注意的一点是,new 一个Promise对象是瞬间执行的,异步的是promise.then的部分。

下图中,主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部API,它们在"任务队列"中加入各种事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取"任务队列",依次执行那些事件所对应的回调函数。

image-20201207170413285

下面是一段代码的执行动画:

preview

下面在招新题上来一个复杂一点的扩展:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
console.log(1);
setInterval(function () {
console.log(100);
}, 1000);

var promise = new Promise(
function (resolve, reject) {
console.log(3);
resolve();
var promise2 = new Promise(
function (resolve, reject) {
console.log(10);
resolve();
console.log(11);
}
);
promise2.then(function () {
console.log(6);
});
}
)
promise.then(function () {
console.log(4);
});
setTimeout(function () {
console.log(2);
}, 500);
promise.then(function () {
console.log(57);
});
console.log(5);

请你不通过控制台运行,直接写出执行结果吧!


[参考资料]: